Learning GraphQL with iOS: The Complete Tutorial

A from-zero, ultra-detailed guide to GraphQL for intermediate iOS developers. Assumes no prior knowledge of GraphQL but assumes solid fluency in Swift, Xcode, async/await, SwiftUI, and REST APIs.


Table of Contents

Part I — Understanding GraphQL (Concepts Only, No Apollo Yet)

  1. Why GraphQL Exists
  2. Reading Your First Query, Symbol by Symbol
  3. The Schema — The Source of All Truth
  4. The Three Operation Types
  5. GraphQL on the Wire — It's Just JSON Over HTTP
  6. Exploring a GraphQL API with GraphiQL

Part II — Apollo iOS, The Tool

  1. What Apollo Does For You
  2. Installing Apollo iOS, Click by Click
  3. Fetching the Schema
  4. Code Generation, Demystified

Part III — Building Your First Screen

  1. The Apollo Client, Piece by Piece
  2. Your First Query, End to End
  3. Variables — Parameterizing Queries
  4. Fragments — Sharing Selection Sets
  5. Optionality and Nullability in Generated Code

Part IV — Mutations

  1. Mutations Conceptually
  2. Writing and Calling a Mutation
  3. What Happens to Your UI After a Mutation

Part V — The Cache, Where the Magic Lives

  1. Why GraphQL Caching Is Different
  2. Normalization Explained
  3. Cache Policies
  4. Watching Queries
  5. Persisting the Cache to Disk
  6. Manual Cache Operations
  7. Optimistic UI Updates

Part VI — Production Concerns

  1. Authentication and Interceptors
  2. Pagination Patterns
  3. Subscriptions and WebSockets
  4. The Three Layers of Errors
  5. Custom Scalars
  6. File Uploads
  7. Testing GraphQL Code

Part VII — Architecture and SwiftUI

  1. SwiftUI + @Observable Integration
  2. The Repository Pattern
  3. Performance Levers
  4. Pitfalls Compendium

Appendix


Part I — Understanding GraphQL

This whole part has zero Swift code. We're going to build the conceptual model of GraphQL first, because if you skip this and jump straight into Apollo, all the generated Swift types and cache mechanics will feel like noise. Once GraphQL itself clicks, the iOS layer becomes mostly mechanical.

1. Why GraphQL Exists

To understand why GraphQL was invented, picture a screen you've definitely built before: a user profile page in a social app. It needs the user's name and avatar at the top, their bio below, a count of their followers, and a horizontally scrolling row of their three most recent posts.

If you build this with REST, you make some calls something like:

GET /api/users/42                  → name, avatar, bio, joined_at, ...
GET /api/users/42/followers/count  → just a number
GET /api/users/42/posts?limit=3    → array of posts

Three round trips. Each round trip is roughly 150-300ms on a good cell network and easily 500-1000ms on a bad one. The user looks at a half-loaded screen waiting for the slowest one. You add concurrency to fire them in parallel; you still wait for whichever finishes last. Then on top of that:

That's overfetching: getting more bytes than you'll display. It costs network time, parsing time, and battery.

Now imagine you ship the app, and a few months later you decide to add a "verified" checkmark next to the username. The backend team adds an is_verified field to /users/:id. You update your iOS Codable struct to include is_verified: Bool, but old versions of your app — still in the wild on the App Store — never asked for it. They don't break, but they also can't show the checkmark. To fix existing installs, you ship a new app build. The cycle takes weeks.

GraphQL was built (at Facebook, around 2012) to solve exactly these problems for mobile apps. The shift it makes is one sentence long but takes a while to internalize:

The client tells the server what data it wants, and the server returns exactly that — no more, no less.

If your profile screen needs name, avatar, followerCount, and posts.title for the latest three, you write a single query that asks for those fields, in that shape, in one request. The server runs it through its type system, fetches the data, and sends back JSON that mirrors your request shape exactly. One round trip. Zero overfetch.

That's it. That's the core idea. Everything else in this tutorial — schemas, fragments, the cache, Apollo's code generation — is either machinery to make this work in practice, or a consequence of it.

A few clarifications about what GraphQL is and isn't, while we're calibrating expectations:

GraphQL is not a database. It doesn't replace Postgres or anything else. It sits between your client and whatever data sources the server uses; the GraphQL server's job is to take a query, route the work to the underlying data sources, and assemble a response.

GraphQL is not a transport protocol. It typically rides on top of HTTP (often a single POST /graphql endpoint) for queries and mutations, and WebSocket for subscriptions. But the GraphQL spec doesn't require any specific transport. You could ship GraphQL over carrier pigeons if you wanted.

GraphQL is not REST's replacement in every situation. For simple, mostly-read APIs with one or two clients, REST is often fine. GraphQL shines when you have many clients (web, iOS, Android, watchOS), each with different data needs, all hitting the same backend.

GraphQL is strongly typed. Every field on every type has a declared type, and the server enforces it. This is critical context for what's coming: when you generate Swift code from a GraphQL schema, that strong typing carries through. Your iOS code never has to guess whether a field is String? or String — the schema says.

Why iOS especially benefits. App releases are slow (App Review, gradual rollout, users who never update). Network is unreliable and expensive. Screens are heterogeneous (the same User shows up on a profile screen, a comment header, a follower list — each wants a different slice). All three of these are GraphQL's sweet spot. If you've ever built a multi-screen app and felt frustration at how poorly REST scales to it, you'll feel the relief of GraphQL fast.

2. Reading Your First Query, Symbol by Symbol

Let's look at a real query and read it like we've never seen one before. Forget Apollo, forget Swift — just GraphQL.

query GetCountry($code: ID!) {
  country(code: $code) {
    name
    capital
    currency
    continent {
      name
    }
  }
}

Every character in here means something. We'll go word by word.

query — this single keyword tells the server "what follows is a read operation." There are three operation types in GraphQL: query, mutation, and subscription. We'll cover the others in Chapter 4. query means "I'm not changing anything, just reading."

GetCountry — the operation's name. This is yours to choose. The name doesn't affect what runs on the server, but it's important for three reasons. First, server logs and observability tools tag requests by operation name; an unnamed query just shows up as anonymous and you lose visibility. Second, code generators (including Apollo's) use the name to derive Swift type names — GetCountry becomes GetCountryQuery. Third, if you ever put two operations in one document, names are how you tell them apart. Always name your operations. The convention is PascalCase, verb-first: GetCountry, CreatePost, DeleteComment.

($code: ID!) — the operation's variable declaration list, in parentheses, like a function signature.

So $code: ID! reads as: "I will provide a variable called code, which must be a non-null ID." If you forget to send the variable when executing this query, the server rejects the request before even running it.

{ ... } — the selection set. Curly braces in GraphQL enclose the fields you want fetched. Every non-leaf field needs one. There's no implicit "give me all fields" — you spell out what you want, always.

country(code: $code) — a field call. country is a top-level field on the schema's Query type (we'll explain Query in Chapter 3). It takes one argument named code, and we're passing it the value of the $code variable.

A note on argument names: code: $code looks redundant, but it isn't. The left code is the argument name as defined on the field (set by the schema author). The right $code is our variable name. They happen to match here. We could just as well write country(code: $countryCode) if we'd named our variable $countryCode.

name, capital, currency — leaf fields on the Country type. Each one is a scalar value, so they don't need their own selection sets. The query is asking: "for the country I just looked up, give me its name, capital, and currency."

continent { name }continent is not a leaf. On the Country type, continent returns another object — a Continent. So we need a selection set for it. Inside, we ask for just the continent's name. We could ask for more (code, countries for sibling countries, etc.), but we don't need them on this screen.

When the server runs this query with code = "CA", the response will look like:

{
  "data": {
    "country": {
      "name": "Canada",
      "capital": "Ottawa",
      "currency": "CAD",
      "continent": {
        "name": "North America"
      }
    }
  }
}

Look closely at the response. Now look back at the query. They're the same shape. The query is a tree of field names; the response is the same tree with values filled in. That isomorphism is GraphQL's central design move and it's what makes the language pleasant to write and to consume.

A few subtler things this example doesn't show, but you should know exist:

Aliases. If you wanted to fetch two countries in one query, you'd hit a name collision: country(code: "CA") { name } and country(code: "US") { name } both produce a field called country in the response. To disambiguate, you use an alias:

query GetTwoCountries {
  ca: country(code: "CA") { name }
  us: country(code: "US") { name }
}

Response:

{ "data": { "ca": { "name": "Canada" }, "us": { "name": "United States" } } }

The ca: and us: rename the fields in the response. Aliases are also useful when you want the same field with different selection sets, or different arguments.

Comments. GraphQL uses # for comments:

query GetCountry($code: ID!) {
  country(code: $code) {
    name      # display name in user's locale
    capital
  }
}

Whitespace. GraphQL doesn't care about whitespace between tokens. You could put the whole query on one line. Don't, obviously — but the parser doesn't care.

__typename. Every type in GraphQL exposes a magic field called __typename (with two underscores). It returns the runtime concrete type name as a string. It's hugely useful when you have unions or interfaces (Chapter 14 details), and Apollo uses it under the hood for caching. You'll see it appearing automatically in Apollo's generated requests.

3. The Schema — The Source of All Truth

A GraphQL server publishes a schema: a complete, machine-readable description of every type, every field, every argument, every operation it accepts. Think of it as the server's exhaustive type contract — the thing your Codable User struct should ideally have been generated from, but never was.

The schema is written in a small, declarative language called SDL (Schema Definition Language). It looks a bit like Swift protocols mixed with TypeScript interfaces. Here's a small but realistic schema:

"""
A real-world country.
"""
type Country {
  code: ID!
  name: String!
  capital: String
  emoji: String!
  currency: String
  languages: [Language!]!
  continent: Continent!
}

type Continent {
  code: ID!
  name: String!
  countries: [Country!]!
}

type Language {
  code: ID!
  name: String
  native: String
  rtl: Boolean!
}

type Query {
  country(code: ID!): Country
  countries(filter: CountryFilterInput): [Country!]!
  continent(code: ID!): Continent
}

input CountryFilterInput {
  code: StringQueryOperatorInput
  continent: StringQueryOperatorInput
}

input StringQueryOperatorInput {
  eq: String
  in: [String!]
}

Let's go feature by feature.

3.1 Object types

type Country { ... } defines an object type named Country. Object types are the bread and butter — almost everything in your schema is one. The body declares fields: each field has a name, a colon, and a type.

The lines like name: String! declare that every Country value has a name field, and that field is a non-null String. Read the : as "of type" and ! as "required."

3.2 Built-in scalars

GraphQL ships with five scalar types that any server understands:

Scalar Meaning JSON form
Int 32-bit signed integer number
Float Double-precision float number
String UTF-8 string string
Boolean true/false boolean
ID Opaque identifier (string-shaped) string

ID is interesting because it's a string semantically — but the schema author is signaling intent: "this is a primary key, not human-readable text. Don't display it." Apollo and other tools may use ID fields for cache keys (we'll see this in Chapter 20).

3.3 Custom scalars

Servers can define their own scalars beyond the built-ins. Common ones:

scalar DateTime
scalar URL
scalar UUID
scalar JSON

These are placeholders — the schema declares them, but the server defines how they serialize (typically DateTime is an ISO-8601 string, URL is a string, JSON is an arbitrary nested structure). When you pull the schema into your iOS client, you need to tell Apollo how to map these scalars to Swift types like Date and URL. Chapter 30 covers this in detail.

3.4 Nullability — the most important detail

This bears repeating because it's the #1 thing iOS devs miss when first reading schemas:

That single ! carries a load-bearing semantic guarantee. Backends that mark too many fields nullable produce miserable Swift code with optionals everywhere. Backends that nail down nullability produce ergonomic code. If you have any input on schema design, fight for accuracy here.

It applies to lists too:

Memorize this table. You'll read it instinctively after a week.

3.5 The special operation types

The schema designates three special types — Query, Mutation, and Subscription — as the operation roots. They aren't structurally different from any other object type; they're just the entry points. Every field on Query is a thing the client can ask for at the top of a query operation. Every field on Mutation is a thing the client can call to change data. Every field on Subscription is a stream the client can subscribe to.

type Query {
  country(code: ID!): Country
  countries(filter: CountryFilterInput): [Country!]!
}

This declares two top-level "entry points": country (takes an ID!, returns a nullable Country — null if not found) and countries (takes an optional filter, always returns an array). When you write query GetCountry { country(code: "CA") { name } }, the server starts at the Query type and resolves country from there.

Most schemas have all three operation roots. Some only have Query (read-only APIs). The Countries API we'll use in Part II is read-only, so it has no Mutation or Subscription.

3.6 Input types

When a field takes a complex argument — not just a scalar but a structured object — the schema uses an input type:

input CountryFilterInput {
  code: StringQueryOperatorInput
  continent: StringQueryOperatorInput
}

input is like type but limited: input types can't have other input types nested inside cyclically, can't have computed fields with arguments, etc. They're meant for "a bag of values you pass in." On the client, input types map to Swift structs.

3.7 Enums

enum PaymentStatus {
  PENDING
  COMPLETED
  REFUNDED
  FAILED
}

Enums are exactly what you expect. They're closed sets of named values. Apollo generates a Swift enum on the client. If the server later adds a new case (DISPUTED) and your old client doesn't know about it, Apollo's generated enum exposes a __unknown(String) case so the client doesn't crash.

3.8 Interfaces

Interfaces are like Swift protocols — types declare they "implement" an interface and gain its required fields:

interface Node {
  id: ID!
}

type User implements Node {
  id: ID!
  name: String!
}

type Post implements Node {
  id: ID!
  title: String!
}

A field returning Node could be a User or a Post at runtime. To select fields beyond what the interface declares, you use inline fragments (Chapter 14).

3.9 Unions

Unions are sets of unrelated types:

union SearchResult = User | Post | Comment

Same idea as interfaces but without shared fields. You discriminate with __typename and inline fragments.

3.10 Directives

Every now and then you'll see something with @:

type User {
  email: String! @deprecated(reason: "Use emailAddress instead")
  emailAddress: String!
}

Those are directives — annotations on schema or query elements. @deprecated is built in. Servers can define their own. You won't write directives often as a client, but you'll see @include and @skip for conditional fields:

query Profile($withPosts: Boolean!) {
  me {
    name
    posts @include(if: $withPosts) { title }
  }
}

3.11 Putting it all together

A schema is just a long text document with these elements. Servers expose it (typically via "introspection" — see Chapter 6) so clients can fetch it programmatically. Apollo's code generator reads this document at build time and produces Swift types for everything.

That's the schema. Re-read this chapter once before moving on; everything that follows leans on this.

4. The Three Operation Types

We've mentioned query, mutation, and subscription repeatedly. Here's the full picture.

4.1 Query — read

query GetUser { me { name } }

Queries are the workhorse. They read data. They're side-effect-free. The server may execute fields in parallel for efficiency. Apollo can cache them. You'll write 90%+ of your operations as queries.

4.2 Mutation — write

mutation UpdateAvatar($url: URL!) {
  updateMyAvatar(url: $url) {
    id
    avatarURL
  }
}

Mutations change something on the server. They look syntactically identical to queries except for the keyword and which root type they hit. Crucial difference: the GraphQL spec says fields on Mutation execute serially, in order. Fields on Query may execute in parallel. So if you put two mutations in one document, they run one after the other. That matters for things like "create user, then create their first post" — the second can depend on the first.

Mutations also typically return the changed object — so you can ask for the new state in the same round trip:

mutation Like($postId: ID!) {
  likePost(id: $postId) {
    id
    likeCount       # the new count after liking
    isLikedByMe     # true now
  }
}

This is more than a convenience. The fact that the mutation returns the affected object is what lets Apollo's cache update other screens automatically (Chapter 25). If your backend's mutations return only Bool ("ok!"), you lose this property. Push for backend mutations that return real objects.

4.3 Subscription — stream

subscription PostAdded {
  postAdded {
    id
    title
  }
}

Subscriptions are long-lived. The client opens one (typically over WebSocket), and the server pushes events whenever they happen. Each event is a normal GraphQL response — same data envelope, same selection set evaluation. They feel like a stream of mini-queries.

Use cases: chat messages, live notifications, presence indicators, real-time dashboards. Don't reach for them just because they're cool — they require running a WebSocket, which has its own complexity (auth, reconnects, scaling). For "refresh every 30 seconds," polling is often simpler.

4.4 Operations vs documents

A .graphql document is a file. It can contain multiple operations and any number of fragments (Chapter 14). When you execute, you point at one specific operation by name:

# OperationsForFeed.graphql

query Feed { posts { id title } }
mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { id } }
fragment PostBasics on Post { id title createdAt }

In iOS-land with Apollo's code generation, each operation becomes its own Swift type. So you don't "select" by name in code — you instantiate the right type and call it.

5. GraphQL on the Wire — It's Just JSON Over HTTP

Before we get into Apollo, let's pull back the curtain. People talk about GraphQL like it's exotic, but it isn't — it's JSON over HTTP, with a particular convention.

When your client executes a query, it sends a POST request to the GraphQL endpoint. The body is a JSON object with two keys: query (the GraphQL document, as a string) and variables (an object with the variable values).

curl -X POST https://countries.trevorblades.com/graphql \
  -H "Content-Type: application/json" \
  -d '{
    "query": "query GetCountry($code: ID!) { country(code: $code) { name capital } }",
    "variables": { "code": "CA" }
  }'

The server responds with a JSON object that has (at minimum) a data key:

{
  "data": {
    "country": {
      "name": "Canada",
      "capital": "Ottawa"
    }
  }
}

That's it. That's the entire wire protocol. There's no special encoding, no binary anything, no magic.

A few extra details worth knowing:

The errors key. If anything went wrong, the response also has an errors array:

{
  "data": null,
  "errors": [
    {
      "message": "Country not found",
      "path": ["country"],
      "extensions": { "code": "NOT_FOUND" }
    }
  ]
}

data and errors can both be present at once — that's the partial-success case (Chapter 29).

HTTP status codes. A successful GraphQL response is almost always HTTP 200, even if it contains errors. The HTTP layer says "request reached the server, parsed correctly." The GraphQL layer (the errors array) describes what went wrong with the operation. This trips up REST veterans constantly. A 200 with errors: [...] is normal GraphQL.

Headers. Just Content-Type: application/json. Auth, if needed, goes in Authorization: Bearer ... like any other API. You can add custom headers freely.

GET requests. The spec also allows GET, with query and variables URL-encoded as query parameters. Used for cacheability via HTTP cache. Less common — almost everyone uses POST.

Persisted queries. A production optimization where the client sends only a hash of the query ({ "id": "abc123", "variables": {...} }) and the server resolves it from a registry. Reduces bytes on the wire and prevents arbitrary queries from hitting prod. Apollo iOS supports this; it's an optional layer (Chapter 35).

That's all the wire stuff. Spend a minute running the curl command above against the Countries API in your terminal — see the response come back. Once you internalize "it's just JSON over HTTP," GraphQL stops feeling like a black box.

6. Exploring a GraphQL API with GraphiQL

Here's a thing GraphQL has that REST doesn't: the API documents itself.

Every GraphQL server can be queried for its own schema. This is called introspection. There's a built-in query (__schema) that returns the full type system. Tools use this to provide IDE-like exploration of any GraphQL API.

The most famous of these tools is GraphiQL ("graphical," with an "i") — a free web-based GraphQL IDE. Most GraphQL endpoints serve a GraphiQL UI when you visit them in a browser. Try it now: open https://countries.trevorblades.com/ in your browser. You'll see a split pane: query editor on the left, response on the right, and a "Docs" sidebar on the far right.

A few things to do in GraphiQL once you're in:

  1. Click "Docs" at the top right. You can browse every type, every field, every argument. The documentation comes from the schema's docstrings (the """...""" blocks in SDL). For learning, this is gold.

  2. Start typing a query. GraphiQL autocompletes field names, validates against the schema in real time, and tells you the type of every field you hover over. Try writing:

    query {
      countries {
        name
      }
    }
    

    Press Cmd+Enter (or click the play button). You'll see a list of every country's name come back.

  3. Add more fields:

    query {
      countries {
        name
        emoji
        capital
        continent {
          name
        }
      }
    }
    

    Run again. The response now contains those fields. Notice the response shape mirrors the request shape exactly.

  4. Variables. Below the query editor there's a "Query Variables" pane. Try:

    query GetCountry($code: ID!) {
      country(code: $code) {
        name
        emoji
        capital
      }
    }
    

    And in variables:

    { "code": "CA" }
    

    Run.

GraphiQL is your design tool. When you're building a screen and trying to figure out what fields to ask for, you mock the query in GraphiQL first, refine it until the response is what you want, then copy it into a .graphql file in your iOS project. Get fluent with it before going further. It'll save you hours.

A modern alternative is Apollo Sandbox (sandbox.apollo.dev) — a more sophisticated explorer with the same job. Same idea, more bells and whistles. Either works.

Why introspection matters for iOS. Apollo's code generator uses introspection to fetch the schema. When you run apollo-ios-cli fetch-schema, it runs this exact introspection query against the endpoint, gets back the full schema, and saves it to schema.graphqls. From that point on, code generation is purely local — the schema file is the source of truth.


Part II — Apollo iOS, The Tool

You now understand GraphQL conceptually. Time to wire it into Swift.

7. What Apollo Does For You

Imagine doing GraphQL on iOS without any library. Conceptually it's straightforward — you saw in Chapter 5 that the wire protocol is just JSON over HTTP. So you'd:

  1. Build a URLRequest with POST https://api.example.com/graphql, body {"query": "...", "variables": {...}}.
  2. Send it via URLSession.
  3. Decode the response into some Codable struct you wrote by hand.
  4. Read the data field, ignore (or handle) errors.
  5. Cache somehow. Maybe write your own.

This works. People do it. But you immediately hit annoying friction:

Apollo iOS exists to make this not your problem. It does three big things:

1. Code generation. You write .graphql files. Apollo's CLI reads the schema and your queries, validates them, and generates Swift types — one per operation, one per fragment. The generated types are exactly the shape your query asked for. You never write a Codable struct again for GraphQL data.

2. Normalized cache. Apollo automatically deduplicates objects in memory by __typename + id. Two queries that overlap on the same User share storage. Mutations update the cache; watchers on other queries see the new data instantly. (Chapter 20 has the full picture.)

3. Network plumbing. A pluggable interceptor chain (auth headers, retry, logging), HTTP transport, WebSocket transport for subscriptions, all integrated.

The cost is: you adopt their codegen pipeline and accept their API surface. For most iOS apps, this is unambiguously the right trade. For a tiny app with three queries, it's overkill — and a URLSession-plus-Codable approach might be simpler. Once you have a dozen queries, Apollo wins decisively.

We're targeting Apollo iOS 1.x. Version 1.0 was a major rewrite that introduced the modern type-safe code generator and cleaner APIs. Always check the latest 1.x release before pinning a version.

8. Installing Apollo iOS, Click by Click

Let's actually set up a project. We'll create a fresh Xcode app, add Apollo, configure code generation, and run our first query against the Countries API.

8.1 Create the project

Open Xcode. File → New → Project → iOS → App. Name it CountriesApp. Choose:

Save it somewhere. You'll have a basic ContentView.swift and CountriesAppApp.swift.

8.2 Add the Apollo SPM package

In Xcode: File → Add Package Dependencies... A dialog appears.

In the search bar, paste:

https://github.com/apollographql/apollo-ios

After a moment, the package resolves. On the right, set "Dependency Rule" to Up to Next Major Version with 1.0.0 (or pick the latest 1.x release). Click Add Package.

A second dialog asks which products to add. Apollo iOS is split into modules:

For this tutorial, check Apollo, ApolloWebSocket, and ApolloSQLite, all targeted at CountriesApp. Click Add Package.

Build the project (Cmd+B) to confirm it compiles. The Apollo dependency is now available.

8.3 Install the codegen CLI

The CLI is what reads your .graphql files and generates Swift code. Easiest install via Homebrew:

brew install apollographql/tap/apollo-ios-cli

Verify:

apollo-ios-cli --version
# Should print something like "Apollo iOS CLI 1.x.x"

If you don't use Homebrew, download a release binary from Apollo's GitHub releases and put it in your PATH. Some teams check the binary into the repo at Tools/apollo-ios-cli so CI doesn't depend on Homebrew.

8.4 Decide on project structure

Open Terminal and cd into your CountriesApp directory (the one containing CountriesApp.xcodeproj). Make a few directories:

mkdir -p CountriesApp/GraphQL
mkdir -p CountriesGraphQL

We'll keep .graphql operation files in CountriesApp/GraphQL/, and Apollo will generate Swift code into CountriesGraphQL/ as a separate Swift Package, which we'll then add to the app as a local SPM dependency.

Why a separate package? Because:

8.5 Initialize the codegen config

From the project root (the CountriesApp directory containing the .xcodeproj):

apollo-ios-cli init --schema-namespace CountriesGraphQL --module-type swiftPackageManager

This creates a file apollo-codegen-config.json. Open it in any editor. It'll look something like this (with defaults):

{
  "schemaNamespace": "CountriesGraphQL",
  "input": {
    "operationSearchPaths": ["**/*.graphql"],
    "schemaSearchPaths": ["**/*.graphqls"]
  },
  "output": {
    "testMocks": { "none": {} },
    "schemaTypes": {
      "path": "./CountriesGraphQL",
      "moduleType": { "swiftPackageManager": {} }
    },
    "operations": { "inSchemaModule": {} }
  }
}

Let's update it to be more deliberate. Replace its contents with:

{
  "schemaNamespace": "CountriesGraphQL",
  "input": {
    "operationSearchPaths": ["CountriesApp/GraphQL/**/*.graphql"],
    "schemaSearchPaths": ["CountriesApp/GraphQL/schema.graphqls"]
  },
  "output": {
    "testMocks": { "none": {} },
    "schemaTypes": {
      "path": "./CountriesGraphQL",
      "moduleType": { "swiftPackageManager": {} }
    },
    "operations": { "inSchemaModule": {} }
  },
  "options": {
    "additionalInflectionRules": [],
    "deprecatedEnumCases": "include",
    "schemaDocumentation": "include",
    "selectionSetInitializers": {
      "operations": true,
      "namedFragments": true,
      "localCacheMutations": true
    },
    "warningsOnDeprecatedUsage": "include"
  },
  "schemaDownload": {
    "downloadMethod": {
      "introspection": {
        "endpointURL": "https://countries.trevorblades.com/graphql",
        "httpMethod": { "POST": {} },
        "includeDeprecatedInputValues": false,
        "outputFormat": "SDL"
      }
    },
    "outputPath": "./CountriesApp/GraphQL/schema.graphqls"
  }
}

Walking through the new pieces:

9. Fetching the Schema

Now we tell Apollo to download the schema:

apollo-ios-cli fetch-schema

You should see output like:

Loading Apollo iOS configuration...
Fetching schema...
Fetched schema for CountriesGraphQL.

Open CountriesApp/GraphQL/schema.graphqls. It's the full SDL of the Countries API — every type, every field, every doc comment. Skim it. You'll see types like Country, Continent, Language, plus the Query type listing top-level fields:

type Query {
  continent(code: ID!): Continent
  continents(filter: ContinentFilterInput): [Continent!]!
  country(code: ID!): Country
  countries(filter: CountryFilterInput): [Country!]!
  language(code: ID!): Language
  languages(filter: LanguageFilterInput): [Language!]!
}

This file is now the source of truth for code generation. Re-run apollo-ios-cli fetch-schema whenever the server schema changes. In a real project, you might commit this file to source control so everyone on the team has the same one.

10. Code Generation, Demystified

Now write your first operation. Create the file CountriesApp/GraphQL/GetCountries.graphql with the contents:

query GetCountries {
  countries {
    code
    name
    emoji
    capital
    continent {
      name
    }
  }
}

This is the same query we wrote in Chapter 6 — it asks for every country's basic info.

Now run codegen:

apollo-ios-cli generate

You should see output indicating successful generation. Check the directory tree:

CountriesApp/
├── CountriesApp.xcodeproj
├── CountriesApp/
│   └── GraphQL/
│       ├── GetCountries.graphql
│       └── schema.graphqls
├── CountriesGraphQL/                    ← NEW
│   ├── Package.swift
│   └── Sources/
│       └── CountriesGraphQL/
│           ├── Schema/
│           │   ├── Objects/
│           │   │   ├── Country.graphql.swift
│           │   │   ├── Continent.graphql.swift
│           │   │   └── Query.graphql.swift
│           │   ├── ...
│           └── Operations/
│               └── Queries/
│                   └── GetCountriesQuery.graphql.swift
└── apollo-codegen-config.json

Apollo just emitted a complete Swift Package. Let's read what it generated.

10.1 Add the generated package to your app

In Xcode: File → Add Package Dependencies → Add Local... Choose the CountriesGraphQL directory. Click Add Package. In the next dialog, check CountriesGraphQL and target CountriesApp. Click Add Package.

Build the project. It compiles. You can now import CountriesGraphQL from anywhere in your app code.

10.2 Read the generated query type

Open CountriesGraphQL/Sources/CountriesGraphQL/Operations/Queries/GetCountriesQuery.graphql.swift. You'll see something like:

@_exported import ApolloAPI

public extension CountriesGraphQL {
  class GetCountriesQuery: GraphQLQuery {
    public static let operationName: String = "GetCountries"
    public static let operationDocument: ApolloAPI.OperationDocument = .init(
      definition: .init(
        #"query GetCountries { countries { __typename code name emoji capital continent { __typename name } } }"#
      ))

    public init() {}

    public struct Data: CountriesGraphQL.SelectionSet {
      public let __data: DataDict
      public init(_dataDict: DataDict) { __data = _dataDict }

      public static var __parentType: any ApolloAPI.ParentType {
        CountriesGraphQL.Objects.Query
      }
      public static var __selections: [ApolloAPI.Selection] = [
        .field("countries", [Country].self, arguments: ["filter": .null]),
      ]

      public var countries: [Country] { __data["countries"] }

      // ... nested struct Country with the same pattern ...
    }
  }
}

Don't be intimidated by the boilerplate — most of it is machinery for Apollo's runtime. The important parts are:

The crucial detail: the nested Country is GetCountriesQuery.Data.Countrynot a top-level reusable Country type. It's the slice of Country that this specific query selected. If you write a different query asking for different fields (say, currency), you'll get a different GetCountriesQuery2.Data.Country with currency instead of, say, emoji.

This deliberate non-reuse is the core of Apollo's type safety: you can never read a field your query didn't ask for, because the type literally doesn't have that property. Compile-time guarantee. Compare with hand-rolled Codable where you write a "fat" struct with all possible fields and pray that the server included them.

10.3 The generated operation document

Notice this string inside the generated code:

"query GetCountries { countries { __typename code name emoji capital continent { __typename name } } }"

That's the literal query string that gets sent over the wire. Apollo automatically inserts __typename everywhere — you didn't ask for it, but Apollo needs it for cache normalization (Chapter 20). It's a free hidden field that costs nothing.

10.4 Re-run generate when you change anything

Anytime you:

…you must re-run apollo-ios-cli generate. Otherwise your generated code is stale.

For automation, you can add a "Run Script" build phase to your app target:

cd "$SRCROOT" && /opt/homebrew/bin/apollo-ios-cli generate

Place it before "Compile Sources." Now every build regenerates. Some teams skip this and run codegen manually + commit generated code to git (so CI builds don't depend on the CLI). Either approach is valid.

Tip. When working with a backend that's actively changing the schema, re-fetch the schema first thing in the morning. A stale schema + a server change means your old queries silently 400 against the new server. Code generation will catch most schema-vs-query mismatches at build time.


Part III — Building Your First Screen

Now we put it all together. This part walks through creating a real, working screen that lists countries.

11. The Apollo Client, Piece by Piece

Create a new Swift file in your app target: CountriesApp/Network/ApolloNetwork.swift. Here's the basic setup:

import Apollo
import CountriesGraphQL
import Foundation

enum Network {
    static let apollo: ApolloClient = {
        let url = URL(string: "https://countries.trevorblades.com/graphql")!
        let store = ApolloStore()
        let urlSessionClient = URLSessionClient()

        let interceptorProvider = DefaultInterceptorProvider(
            client: urlSessionClient,
            shouldInvalidateClientOnDeinit: true,
            store: store
        )

        let transport = RequestChainNetworkTransport(
            interceptorProvider: interceptorProvider,
            endpointURL: url
        )

        return ApolloClient(networkTransport: transport, store: store)
    }()
}

Read it line by line.

url — the GraphQL endpoint. One URL serves all your queries, mutations, and (over WebSocket) subscriptions.

store = ApolloStore() — the cache. By default, in-memory only. Apollo's normalized cache lives behind this object. Every fetched response is written here; every observed query reads from here.

urlSessionClient = URLSessionClient() — Apollo's URLSession wrapper. It does the actual HTTP. You can configure it with custom session config (timeouts, proxy, etc.) but the default is fine.

interceptorProvider — interceptors are middleware that run on every request. The DefaultInterceptorProvider ships with a sensible default chain: parse incoming headers, write to cache, request retries on certain failures, etc. You can subclass this to inject your own (auth, logging, custom retry) — Chapter 26.

transport — orchestrates each request through the interceptor chain.

ApolloClient(networkTransport:, store:) — the public API. You'll call .fetch(...), .perform(...), .subscribe(...), .watch(...) on this.

Wrapped in an enum Network namespace so we have a single static accessor: Network.apollo. For non-trivial apps, you'd inject this via a repository or environment value (Chapter 34) instead of using a global.

12. Your First Query, End to End

We already wrote GetCountries.graphql in Chapter 10 and generated the Swift type. Now let's actually run it.

12.1 The naive callback API

Apollo's primitive fetch API is callback-based:

Network.apollo.fetch(query: GetCountriesQuery()) { result in
    switch result {
    case .success(let response):
        if let countries = response.data?.countries {
            print("Got \(countries.count) countries")
            for country in countries.prefix(5) {
                print("- \(country.emoji) \(country.name)")
            }
        }
        if let errors = response.errors {
            print("Errors:", errors)
        }
    case .failure(let error):
        print("Network error:", error)
    }
}

What this is doing:

Run this from ContentView.swift (onAppear { ... }) and check the Xcode console. You should see country names print.

12.2 The async/await wrapper

The callback API is fine but feels archaic in 2026. Apollo iOS doesn't ship a built-in async wrapper, so we'll write a small one. Create CountriesApp/Network/ApolloClient+Async.swift:

import Apollo
import ApolloAPI

extension ApolloClient {
    func fetchAsync<Query: GraphQLQuery>(
        query: Query,
        cachePolicy: CachePolicy = .returnCacheDataElseFetch
    ) async throws -> Query.Data {
        try await withCheckedThrowingContinuation { continuation in
            fetch(query: query, cachePolicy: cachePolicy) { result in
                switch result {
                case .success(let response):
                    if let data = response.data {
                        continuation.resume(returning: data)
                    } else if let firstError = response.errors?.first {
                        continuation.resume(throwing: firstError)
                    } else {
                        continuation.resume(throwing: GraphQLClientError.noData)
                    }
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }
    }

    func performAsync<Mutation: GraphQLMutation>(
        mutation: Mutation
    ) async throws -> Mutation.Data {
        try await withCheckedThrowingContinuation { continuation in
            perform(mutation: mutation) { result in
                switch result {
                case .success(let response):
                    if let data = response.data {
                        continuation.resume(returning: data)
                    } else if let firstError = response.errors?.first {
                        continuation.resume(throwing: firstError)
                    } else {
                        continuation.resume(throwing: GraphQLClientError.noData)
                    }
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }
    }
}

enum GraphQLClientError: Error {
    case noData
}

GraphQLError from Apollo conforms to Error, so we can throw it directly. The firstError approach is simplistic — for partial-success scenarios you'd want to surface both data and errors. We'll refine this in Chapter 29.

12.3 A view model

Create CountriesApp/Features/Countries/CountriesViewModel.swift:

import CountriesGraphQL
import Observation

@Observable
final class CountriesViewModel {
    enum State {
        case idle
        case loading
        case loaded([GetCountriesQuery.Data.Country])
        case failed(Error)
    }

    var state: State = .idle

    @MainActor
    func load() async {
        state = .loading
        do {
            let data = try await Network.apollo.fetchAsync(query: GetCountriesQuery())
            state = .loaded(data.countries)
        } catch {
            state = .failed(error)
        }
    }
}

A few notes:

12.4 The view

Replace ContentView.swift:

import CountriesGraphQL
import SwiftUI

struct ContentView: View {
    @State private var vm = CountriesViewModel()

    var body: some View {
        NavigationStack {
            content
                .navigationTitle("Countries")
        }
        .task { await vm.load() }
    }

    @ViewBuilder
    private var content: some View {
        switch vm.state {
        case .idle, .loading:
            ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
        case .loaded(let countries):
            List(countries, id: \.code) { country in
                HStack {
                    Text(country.emoji ?? "🏳️")
                        .font(.largeTitle)
                    VStack(alignment: .leading) {
                        Text(country.name)
                            .font(.headline)
                        Text("Capital: \(country.capital ?? "—")")
                            .font(.caption)
                            .foregroundStyle(.secondary)
                        Text(country.continent.name)
                            .font(.caption2)
                            .foregroundStyle(.tertiary)
                    }
                }
            }
        case .failed(let error):
            ContentUnavailableView(
                "Couldn't load countries",
                systemImage: "exclamationmark.triangle",
                description: Text(error.localizedDescription)
            )
        }
    }
}

Build and run. You'll see a list of every country with its flag, name, capital, and continent. Congratulations — you just built a working GraphQL-powered iOS screen.

A few things to notice in the view code:

13. Variables — Parameterizing Queries

The countries list query had no variables. Most real queries do. Let's add a "country detail" screen that uses one.

13.1 The query

Create CountriesApp/GraphQL/GetCountryDetails.graphql:

query GetCountryDetails($code: ID!) {
  country(code: $code) {
    code
    name
    capital
    emoji
    currency
    phone
    languages {
      code
      name
      native
    }
    continent {
      code
      name
    }
    states {
      code
      name
    }
  }
}

Run apollo-ios-cli generate. A new GetCountryDetailsQuery type appears in the generated module.

13.2 The generated init

Open GetCountryDetailsQuery.graphql.swift (in CountriesGraphQL/Sources/CountriesGraphQL/Operations/Queries/). You'll see something like:

public init(code: ID) {
  self.code = code
}

public var code: ID

public var __variables: Variables? { ["code": code] }

Apollo turned $code: ID! into init(code: ID). You instantiate the query like:

let query = GetCountryDetailsQuery(code: "CA")

The variable code of type ID! became a non-optional ID (which is a typealias for String). If it had been ID (nullable in the schema), it would become GraphQLNullable<ID> — covered in Chapter 15.

13.3 Use the query

Add to your view model:

extension CountriesViewModel {
    @MainActor
    func loadDetails(code: String) async throws -> GetCountryDetailsQuery.Data.Country? {
        let data = try await Network.apollo.fetchAsync(query: GetCountryDetailsQuery(code: ID(code)))
        return data.country
    }
}

ID(code) constructs an ID value from a String. The ID type is a struct wrapping a string and is just there for type discipline.

The country in the response is Country? because the schema declares country(code: ID!): Country (note: no ! after Country) — meaning if the code doesn't match any country, the server returns null.

13.4 Why not just string-interpolate the code?

A common newcomer impulse is:

// DON'T DO THIS
let queryString = "query { country(code: \"\(userInput)\") { name } }"

Reasons not to:

  1. Code generation needs static query strings. Apollo can't generate a Swift type from a string you build at runtime.
  2. Caching uses the query + variables as a key. Different inlined values produce different query strings, causing cache misses for what should be the same query.
  3. Server-side persisted queries. Production setups send only a hash of the query, not the full text. Variables ride alongside. Inlined values defeat this.
  4. Injection. You'd be string-interpolating user input into a query string. Even if GraphQL itself is harder to "inject" than SQL, it's still bad practice.

Variables exist precisely to keep the query a static, hashable artifact and the values a separate concern. Always use variables.

14. Fragments — Sharing Selection Sets

Imagine your app has two screens that show country info: a list (basic info) and a detail screen (full info). They overlap on fields like code, name, emoji, continent.name. Without fragments, you'd write the same selection set twice.

A fragment is a named, reusable selection set on a specific type. It lets you DRY up overlapping queries.

14.1 Define a fragment

Create CountriesApp/GraphQL/Fragments/CountrySummary.graphql:

fragment CountrySummary on Country {
  code
  name
  emoji
  capital
  continent {
    name
  }
}

Read it: "a fragment named CountrySummary, valid on the Country type, selecting these fields."

14.2 Use the fragment

Update your existing queries to spread the fragment using ...:

# GetCountries.graphql
query GetCountries {
  countries {
    ...CountrySummary
  }
}
# GetCountryDetails.graphql
query GetCountryDetails($code: ID!) {
  country(code: $code) {
    ...CountrySummary
    currency
    phone
    languages {
      code
      name
      native
    }
    states {
      code
      name
    }
  }
}

The ...CountrySummary syntax is "spread the fragment here." On the wire, the server fully expands fragments before executing — they're a client-side organization tool, not a server feature.

Run apollo-ios-cli generate. Two things change:

  1. A new top-level Swift type CountrySummary appears (the fragment's generated representation).
  2. Both queries' country structs now have fragments.countrySummary accessors:
    country.fragments.countrySummary.name  // String
    country.fragments.countrySummary.continent.name  // String
    

You can still access fields directly — country.name still works, since the fragment's fields are flattened into the country struct. The fragments.countrySummary form gives you the fragment as a value, which is what enables sharing.

14.3 A reusable SwiftUI cell

Now write one cell that takes the fragment:

import CountriesGraphQL
import SwiftUI

struct CountryCell: View {
    let country: CountrySummary

    var body: some View {
        HStack {
            Text(country.emoji)
                .font(.largeTitle)
            VStack(alignment: .leading) {
                Text(country.name)
                    .font(.headline)
                Text(country.continent.name)
                    .font(.caption)
                    .foregroundStyle(.secondary)
                if let capital = country.capital {
                    Text("Capital: \(capital)")
                        .font(.caption2)
                        .foregroundStyle(.tertiary)
                }
            }
        }
    }
}

And use it from both the list and the detail screen header:

// In the list
List(countries, id: \.code) { country in
    CountryCell(country: country.fragments.countrySummary)
}

// In the detail screen header
CountryCell(country: detail.country.fragments.countrySummary)

One cell, two screens, perfectly shared types. If you change the fragment (say, drop capital), both queries update simultaneously and the cell's compile errors point you at exactly what to fix.

14.4 Inline fragments — for unions and interfaces

A different fragment use case: discriminating types when a field returns an interface or union. Suppose a schema has:

union SearchResult = User | Post | Comment

type Query {
  search(term: String!): [SearchResult!]!
}

You can't just ask for fields on SearchResult because the available fields depend on the concrete type. You use inline fragments to discriminate:

query Search($term: String!) {
  search(term: $term) {
    __typename
    ... on User { id, name }
    ... on Post { id, title }
    ... on Comment { id, body }
  }
}

... on User { ... } reads as "if this thing is a User, also select these fields." Apollo's generated code maps each case to a Swift enum case so you switch on it:

for result in data.search {
    switch result {
    case .user(let user):
        print("User:", user.name)
    case .post(let post):
        print("Post:", post.title)
    case .comment(let comment):
        print("Comment:", comment.body)
    case .none:
        break  // unknown future variant — forward-compatible
    }
}

The .none case is for forward compatibility: if the server adds a new variant to the union your client doesn't recognize, you don't crash, you just hit .none. This is GraphQL's elegant approach to versioning.

14.5 Fragments and the cache

One more reason fragments matter: they're the unit Apollo's normalized cache reasons about. Two queries that both spread CountrySummary over a Country object end up with the same cache entry. Update that country (via a mutation, say), and both screens' watched data refresh automatically. We'll see this play out in Chapters 20 and 22.

15. Optionality and Nullability in Generated Code

You've seen String! vs String and String vs String? in passing. Let's nail the model, because this is where iOS devs trip up.

15.1 The four cases for response fields

Combining the schema's nullability with Apollo's code generation:

Schema Swift property type Notes
name: String! String Server guarantees non-null.
name: String String? Server may return null.
tags: [Tag!]! [Tag] Always an array, never-null elements.
tags: [Tag!] [Tag]? Array may be null.
tags: [Tag]! [Tag?] Array always present, elements may be null.
tags: [Tag] [Tag?]? Both may be null.

These are direct mechanical translations. No surprises.

15.2 Variables and GraphQLNullable<T>

The interesting optionality is on variables. GraphQL has three states for an input variable:

  1. Provided with a non-null value. {"code": "CA"}.
  2. Provided with null explicitly. {"code": null}.
  3. Omitted entirely. {} (no code key at all).

For a non-null input variable ($code: ID!), only state 1 is valid. The schema forbids the others.

For a nullable input variable ($code: ID), all three states are valid — and they may mean different things to the server. Consider an "update" mutation:

mutation UpdateUser($id: ID!, $name: String, $bio: String) {
  updateUser(id: $id, name: $name, bio: $bio) { id }
}

A reasonable server semantics might be: "if a field is omitted, leave it unchanged. If a field is null, clear it." So:

You need to be able to express all three. Swift's String? only has two states (some or nil), so it can't directly model this. Apollo's solution is GraphQLNullable<T>, an enum with three cases:

enum GraphQLNullable<Wrapped> {
    case some(Wrapped)   // explicit value
    case null            // explicit null
    case none            // omitted (variable not sent)
}

When a variable is nullable, the generated initializer takes GraphQLNullable<T> instead of T?:

// $name: String  →  name: GraphQLNullable<String>
let mutation = UpdateUserMutation(
    id: "42",
    name: .some("Ada"),       // pass a value
    bio: .null                // pass null explicitly
)

// or omit:
let mutation = UpdateUserMutation(
    id: "42",
    name: .some("Ada"),
    bio: .none                // don't include bio in variables
)

You'll mostly construct .some(value) for "I have a value." Use .null when you specifically want to clear or null something. Use .none when you want to omit.

15.3 Bridging from Swift Optional

You usually have data already in Swift Optional form — say, an bio: String? from your view model. To convert, an extension is handy:

extension GraphQLNullable {
    init(optional: Wrapped?) {
        self = optional.map { .some($0) } ?? .none  // nil → .none
    }
}

// Usage
let bio: String? = userPreference
let mutation = UpdateUserMutation(id: "42", name: .none, bio: GraphQLNullable(optional: bio))

That treats nil as "omit." If your semantics are "nil means clear," use .null instead:

extension GraphQLNullable {
    init(optionalAsNull: Wrapped?) {
        self = optionalAsNull.map { .some($0) } ?? .null
    }
}

Pick whichever convention matches your backend's behavior and apply it consistently.

Gotcha. This is the single most confusing thing for new Apollo iOS users. Spend ten minutes drilling the three cases until they're automatic. Once they are, the rest of Apollo's API is straightforward.


Part IV — Mutations

You can read data. Now let's change it.

16. Mutations Conceptually

A mutation is a write operation. Syntactically, it's just like a query but starts with mutation:

mutation CreatePost($input: CreatePostInput!) {
  createPost(input: $input) {
    id
    title
    body
    publishedAt
    author {
      id
      name
    }
  }
}

A few things worth dwelling on:

Same selection set rules. You select fields on the returned object the same way as queries. The fields you select after createPost(...) are the fields that come back in the response.

Why ask for fields back? Because the server is going to give you the freshly created object. Asking for id, title, etc. means you don't need a follow-up query. Just as importantly, those returned fields go into Apollo's cache, automatically updating any watcher that's looking at this object. We'll exploit this in Chapter 25.

Serial execution. If you put multiple top-level fields in a mutation document, the server runs them in order:

mutation Cleanup {
  deleteOldDrafts(beforeDate: "2024-01-01") { count }
  archiveStaleProjects { count }
}

deleteOldDrafts runs to completion first, then archiveStaleProjects. Compare with queries, where the spec allows parallel execution.

The input convention. By GraphQL community convention (not spec), mutations take a single argument named input, of an input type, rather than many positional arguments. So:

# Convention
mutation CreatePost($input: CreatePostInput!) { ... }

# Not conventional
mutation CreatePost($title: String!, $body: String!, $tags: [String!]) { ... }

The reason: input types evolve cleanly. Adding a field to CreatePostInput doesn't change the mutation's signature on the wire. Adding a positional argument does.

17. Writing and Calling a Mutation

The Countries API is read-only, so we'll switch to a hypothetical posts API for examples in this part.

17.1 The .graphql file

Imagine a posts schema:

input CreatePostInput {
  title: String!
  body: String!
  tags: [String!]
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
}

Your operation:

mutation CreatePost($input: CreatePostInput!) {
  createPost(input: $input) {
    id
    title
    body
    publishedAt
    author {
      id
      name
    }
  }
}

After apollo-ios-cli generate, you get:

17.2 Calling the mutation

func createPost(title: String, body: String, tags: [String]?) async throws -> CreatePostMutation.Data.CreatePost {
    let input = CreatePostInput(
        title: title,
        body: body,
        tags: GraphQLNullable(optional: tags)
    )
    let data = try await Network.apollo.performAsync(mutation: CreatePostMutation(input: input))
    return data.createPost
}

Note perform (not fetch) — perform is the API for mutations. It bypasses the cache for the request itself (mutations always hit the network) but writes the response into the cache.

Use it from a view:

struct CreatePostView: View {
    @State private var title = ""
    @State private var body = ""
    @State private var isCreating = false

    var body: some View {
        Form {
            TextField("Title", text: $title)
            TextEditor(text: $body)
            Button("Create") {
                Task {
                    isCreating = true
                    defer { isCreating = false }
                    do {
                        let post = try await createPost(title: title, body: body, tags: nil)
                        print("Created post:", post.id)
                    } catch {
                        print("Failed:", error)
                    }
                }
            }
            .disabled(title.isEmpty || isCreating)
        }
    }
}

That's the basic flow. The real questions are: how does this affect the rest of my UI? — which we'll answer in the next chapter.

18. What Happens to Your UI After a Mutation

The mutation runs, the response comes back, the cache writes the new Post. Now: a feed screen elsewhere in your app is showing a list of posts. Does it update?

Not automatically. Apollo can't know your new post belongs at the top of the feed (or anywhere in particular). You have to tell it. Three strategies, in order of increasing sophistication:

18.1 Refetch the affected query

Easiest, dumbest, often correct:

let post = try await createPost(title: title, body: body, tags: nil)
// Refetch the feed
_ = try await Network.apollo.fetchAsync(
    query: FeedQuery(),
    cachePolicy: .fetchIgnoringCacheData
)

.fetchIgnoringCacheData forces a network fetch and updates the cache. Watchers on FeedQuery (Chapter 22) get the new data automatically.

Cost: an extra round trip. Acceptable for low-traffic screens or small feeds. Wasteful for huge lists.

18.2 Update the cache manually

For surgical updates, you read the existing cached query data, mutate it, and write it back. Apollo handles this via client.store.withinReadWriteTransaction:

try await Network.apollo.store.withinReadWriteTransaction { transaction in
    try transaction.update(query: FeedQuery()) { data in
        // data.feed is the cached feed array. Insert the new post at the top.
        // (Construction of the new feed entry depends on your generated types.)
        let newEntry = FeedQuery.Data.Feed(/* fields... */)
        data.feed.insert(newEntry, at: 0)
    }
}

Apollo notifies all watchers on FeedQuery that data changed; UIs update.

Caveats:

18.3 Optimistic updates

The classy version: update the cache immediately, before the server responds, with a guess at the result. Render UI from that. When the server responds, reconcile. Covered in detail in Chapter 25.

For a "like" button, optimistic updates are essential — users tap and expect instant feedback. For a "create post" flow, where the user's already going to navigate away to a confirmation screen, a refetch or manual update is fine.


Part V — The Cache, Where the Magic Lives

This is the part most tutorials gloss over. Misunderstanding the cache is the #1 source of GraphQL bugs in production iOS apps. Take your time here.

19. Why GraphQL Caching Is Different

In REST, each endpoint is a separate URL. URLSession's default behavior, with URLCache, is HTTP caching: same URL, same response (with Cache-Control and ETag headers respected), served from disk. It works automatically. It's keyed by URL.

GraphQL has one URL — https://api.example.com/graphql. Every query goes through it. HTTP caching is useless: same URL means the cache treats every request as a hit on the same resource. You'd need to key by the request body (the query + variables), which URLCache doesn't do natively.

So GraphQL clients implement caching at a higher level. Apollo's choice — and it's a brilliant one — is normalized caching: store objects by their identity, not by request path.

20. Normalization Explained

Let's walk through the idea concretely.

20.1 The naive approach

If the cache were just (query string, variables) → response JSON, you'd hit a problem. Suppose:

query A { me { id name email } }
query B { feed { id title author { id name } } }

Both queries return some User data. With a naive per-query cache:

Now a mutation updates the user's name. We need both screens to update. With a naive cache, each entry is independent — we'd have to know to invalidate both.

20.2 Apollo's normalized cache

Apollo instead stores objects in a flat, key-value structure. Every object — wherever it appears — gets its own entry, keyed by __typename + id. References between objects are stored as keys, not nested copies.

After running queries A and B above, the cache looks like:

User:1  → { id: "1", name: "Ada", email: "ada@x.com" }
Post:42 → { id: "42", title: "On Engines", author: → User:1 }
Post:43 → { id: "43", title: "Programs", author: → User:1 }

ROOT_QUERY → {
  me                      → → User:1,
  feed                    → [→ Post:42, → Post:43]
}

The is a reference (a string like "User:1"), not a copy. The user data lives in one place.

When a mutation comes back with { "id": "1", "name": "Ada Lovelace", ... }, Apollo writes those fields into User:1. Both queries' watchers see the change because they both ultimately read from User:1.

This is the same pattern as Core Data with object identity, or Redux with normalized state. It works for the same reasons: a single source of truth per entity.

20.3 How Apollo computes the cache key

By default, the key is <__typename>:<id>. So:

For this to work, Apollo needs:

  1. Every selection set on a typed object must include __typename. Apollo automatically inserts __typename into every selection set it generates — that's why you see it in the operation document strings.
  2. Every selection set on a typed object should include id (or whatever your cache key field is). This is on you. Forget to select id and the object can't be cached normally — it gets stored as a per-query entry with no cross-query sharing.

If your schema uses something other than id (like code for countries, since the Countries API uses ISO codes as keys), you configure a custom resolver. Create CountriesApp/Network/CacheKeyConfiguration.swift:

import ApolloAPI
import CountriesGraphQL

extension CountriesGraphQL.SchemaMetadata {
    public static func cacheKeyInfo(for type: ApolloAPI.Object, object: ApolloAPI.ObjectData) -> ApolloAPI.CacheKeyInfo? {
        switch type {
        case CountriesGraphQL.Objects.Country, CountriesGraphQL.Objects.Continent, CountriesGraphQL.Objects.Language:
            return try? CacheKeyInfo(jsonValue: object["code"])
        default:
            return nil  // fall back to default (id)
        }
    }
}

Now the cache uses code as the key for Country/Continent/Language. Without this, Apollo doesn't know how to identify these objects and falls back to per-query caching.

Practical rule. Always include id (or your equivalent) in every selection set on a typed object. Even if you don't use the field in the UI. The cache needs it.

20.4 What happens when fields don't match

What if Query A asks for User { id name email } and Query B asks for User { id name avatarURL }? Both write to User:1. The cache merges:

User:1 → { id: "1", name: "Ada", email: "ada@x.com", avatarURL: "https://..." }

If a third query later asks for User { id name email }, Apollo can serve the response from the cache without hitting the network, because all three fields (id, name, email) are present. If it asks for something the cache doesn't have (User { id phoneNumber }), Apollo goes to the network.

This per-field cache hit logic is the engine behind Apollo's "smart" cache policies. The cache isn't all-or-nothing; it knows which fields it has and which it doesn't.

21. Cache Policies

Every fetch call takes a CachePolicy argument that decides how the cache and network interact:

Policy Behavior
.returnCacheDataElseFetch (default) Try cache. If it has all requested fields, return them. Otherwise, network.
.fetchIgnoringCacheData Always network. Update cache with result.
.fetchIgnoringCacheCompletely Always network. Don't even update the cache.
.returnCacheDataDontFetch Cache only. Returns nil if cache miss. Never goes to network.
.returnCacheDataAndFetch Return cache immediately, then fetch and update. The completion handler fires twice.

Use cases:

Most async wrappers (including the simple one in Chapter 12) only handle the first callback. To handle both, use client.watch instead of fetch. That's the next chapter.

22. Watching Queries

fetch is a one-shot. watch is a subscription to cache changes. You get a callback every time the underlying data changes — initial fetch, network refresh, mutation that touched the data, manual cache write, anything.

let watcher = Network.apollo.watch(
    query: GetCountriesQuery(),
    cachePolicy: .returnCacheDataAndFetch
) { result in
    switch result {
    case .success(let response):
        if let countries = response.data?.countries {
            // Update UI with countries
        }
    case .failure(let error):
        print("watch error:", error)
    }
}

// Later, when done:
watcher.cancel()

The watcher object is a GraphQLQueryWatcher. Hold onto it. Cancel it when done (typically when the view goes away). Forgetting to cancel is the #1 cause of memory leaks in Apollo iOS apps.

.returnCacheDataAndFetch with watch gives you the ideal app-screen experience: cache renders instantly, network updates the cache, the watcher fires again with fresh data. From the UI's perspective, it just keeps updating with the latest available info.

We'll wrap this in a SwiftUI-friendly observer in Chapter 33 so cancellation is automatic.

23. Persisting the Cache to Disk

The default ApolloStore() is in-memory only. Cache is wiped on app relaunch. For most apps you want cache that survives — fast cold starts, offline browse, less network.

import Apollo
import ApolloSQLite

let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let cacheURL = documentsURL.appendingPathComponent("apollo_cache.sqlite")
let cache = try SQLiteNormalizedCache(fileURL: cacheURL)
let store = ApolloStore(cache: cache)

Pass store to ApolloClient the same way as before. Now your normalized cache is backed by SQLite. Reads and writes go through it transparently.

Implications:

To wipe:

Network.apollo.clearCache { _ in }

That nukes the cache contents (and clears the SQLite file). Always do this on logout.

24. Manual Cache Operations

Beyond fetch/watch, you can read and write the cache directly. Useful for:

24.1 Reading

Network.apollo.store.withinReadTransaction { transaction in
    let data = try transaction.read(query: GetCountriesQuery())
    print("Cached countries:", data.countries.count)
} completion: { result in
    if case .failure(let error) = result {
        print("Read error:", error)
    }
}

read throws if the cache doesn't have all the requested fields.

24.2 Writing

Network.apollo.store.withinReadWriteTransaction { transaction in
    try transaction.write(
        data: GetCountriesQuery.Data(countries: customCountries),
        for: GetCountriesQuery()
    )
} completion: { result in
    // ...
}

Construct the Data value yourself (using the memberwise init) and write it into the cache against a specific query.

24.3 Updating after mutation

The pattern from Chapter 18, written more carefully:

let createdPost = try await Network.apollo.performAsync(mutation: CreatePostMutation(input: input))

try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
    Network.apollo.store.withinReadWriteTransaction({ transaction in
        try transaction.update(query: FeedQuery()) { data in
            // Mutate the cached feed list in place
            let newEntry = FeedQuery.Data.Feed(/* construct from createdPost */)
            data.feed.insert(newEntry, at: 0)
        }
    }, completion: { result in
        cont.resume(with: result)
    })
}

update is sugar around read + write. Apollo notifies watchers; lists update across the app.

24.4 Inspecting the cache for debugging

For day-to-day debugging, install a logging interceptor (Chapter 26) and watch the console. For deeper inspection, you can dump the SQLite file (use any SQLite browser) or read raw cache keys via store.withinReadTransaction { transaction in ... }.

Apollo Studio (a paid product) has a more sophisticated dev-time cache inspector. For most apps, console logging is enough.

25. Optimistic UI Updates

Apply the change locally before the server responds. The UI feels instant. The server's eventual response either confirms or corrects the local state.

25.1 The pattern

For a "like" mutation:

mutation LikePost($id: ID!) {
  likePost(id: $id) {
    id
    likeCount
    isLikedByMe
  }
}
func likePost(_ post: Post) {
    Network.apollo.perform(
        mutation: LikePostMutation(id: post.id),
        publishResultToStore: true,
        optimisticData: LikePostMutation.Data(
            likePost: .init(
                id: post.id,
                likeCount: post.likeCount + 1,
                isLikedByMe: true
            )
        )
    ) { result in
        // Real response handling
    }
}

What happens:

  1. Immediately: Apollo writes the optimistic data into the cache. Watchers fire. The like count visually increments.
  2. In flight: Apollo sends the mutation to the server.
  3. Response received: Apollo replaces optimistic data with the real server response. If they match (your guess was right), watchers don't fire (no change). If they differ (server reports a different count because of concurrent likes), watchers fire with the corrected number.
  4. Failure: Apollo rolls back the optimistic data — like count snaps back to its old value. Watchers fire with the original numbers.

For this to work, the optimistic data must be a fully-formed Data value of the mutation's response type. That's why you need selectionSetInitializers.operations: true in the codegen config (Chapter 8).

25.2 When to use optimistic updates

25.3 Rollback semantics

If the network errors out, Apollo restores the pre-optimistic state automatically. The user sees the like count snap back. You should also surface an error toast or revert the user's tap state.

Optimistic updates are the single biggest UX upgrade you can extract from Apollo's normalized cache. Once you start using them, plain mutations feel sluggish.


Part VI — Production Concerns

The previous parts give you working code. The following parts are what you need to ship.

26. Authentication and Interceptors

Real apps need auth. Apollo's mechanism for "stuff that runs on every request" is the interceptor chain — middleware that runs in order, each interceptor having a chance to modify the request, the response, or short-circuit the chain.

26.1 An auth header interceptor

Add Authorization: Bearer <token> to every outgoing request:

import Apollo
import ApolloAPI
import Foundation

final class AuthorizationInterceptor: ApolloInterceptor {
    var id: String = UUID().uuidString
    private let tokenProvider: () async -> String?

    init(tokenProvider: @escaping () async -> String?) {
        self.tokenProvider = tokenProvider
    }

    func interceptAsync<Operation>(
        chain: any RequestChain,
        request: HTTPRequest<Operation>,
        response: HTTPResponse<Operation>?,
        completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void
    ) {
        Task {
            if let token = await tokenProvider() {
                request.addHeader(name: "Authorization", value: "Bearer \(token)")
            }
            chain.proceedAsync(
                request: request,
                response: response,
                interceptor: self,
                completion: completion
            )
        }
    }
}

Interceptors implement interceptAsync(chain:request:response:completion:). Each one either modifies the request/response and calls chain.proceedAsync(...) to continue, or short-circuits with chain.handleErrorAsync(...) or by calling completion directly.

To wire this into the client, subclass DefaultInterceptorProvider:

final class AppInterceptorProvider: DefaultInterceptorProvider {
    private let tokenProvider: () async -> String?

    init(client: URLSessionClient, store: ApolloStore, tokenProvider: @escaping () async -> String?) {
        self.tokenProvider = tokenProvider
        super.init(client: client, store: store)
    }

    override func interceptors<Operation: GraphQLOperation>(for operation: Operation) -> [any ApolloInterceptor] {
        var chain = super.interceptors(for: operation)
        chain.insert(AuthorizationInterceptor(tokenProvider: tokenProvider), at: 0)
        return chain
    }
}

Use it:

let provider = AppInterceptorProvider(
    client: URLSessionClient(),
    store: store,
    tokenProvider: { await TokenStore.shared.currentAccessToken() }
)

The token provider is async because typical token stores are actor-isolated. Always grab a fresh token at request time, not at provider construction time — tokens expire.

26.2 Token refresh on 401

A more sophisticated interceptor: catch a 401 response, refresh the access token, retry the original request:

final class TokenRefreshInterceptor: ApolloInterceptor {
    var id: String = UUID().uuidString
    private let refresh: () async throws -> Void

    init(refresh: @escaping () async throws -> Void) {
        self.refresh = refresh
    }

    func interceptAsync<Operation>(
        chain: any RequestChain,
        request: HTTPRequest<Operation>,
        response: HTTPResponse<Operation>?,
        completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void
    ) {
        guard let response, response.httpResponse.statusCode == 401 else {
            chain.proceedAsync(request: request, response: response, interceptor: self, completion: completion)
            return
        }

        Task {
            do {
                try await refresh()
                chain.retry(request: request, completion: completion)
            } catch {
                chain.handleErrorAsync(error, request: request, response: response, completion: completion)
            }
        }
    }
}

chain.retry(request:completion:) re-runs the request through the chain — picking up the new token from the auth interceptor. Place this interceptor after parsing in the chain, so it sees the parsed status code.

A subtle production detail: if many requests fire simultaneously and all hit 401, you'll trigger N concurrent refreshes. Serialize the refresh through an actor or a single Task to avoid storms.

26.3 Logging interceptor

For development, log every operation:

final class LoggingInterceptor: ApolloInterceptor {
    var id: String = UUID().uuidString

    func interceptAsync<Operation>(
        chain: any RequestChain,
        request: HTTPRequest<Operation>,
        response: HTTPResponse<Operation>?,
        completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void
    ) {
        print("→ \(Operation.operationName)", request.operationIdentifier ?? "")
        let start = Date()
        chain.proceedAsync(request: request, response: response, interceptor: self) { result in
            let elapsed = Date().timeIntervalSince(start)
            switch result {
            case .success(let r):
                let source = r.source.description
                print("← \(Operation.operationName) [\(source)] \(Int(elapsed * 1000))ms")
            case .failure(let error):
                print("✗ \(Operation.operationName) \(error.localizedDescription)")
            }
            completion(result)
        }
    }
}

Insert it at the top of the chain only in DEBUG builds:

override func interceptors<Operation: GraphQLOperation>(for operation: Operation) -> [any ApolloInterceptor] {
    var chain = super.interceptors(for: operation)
    chain.insert(AuthorizationInterceptor(tokenProvider: tokenProvider), at: 0)
    #if DEBUG
    chain.insert(LoggingInterceptor(), at: 0)
    #endif
    return chain
}

response.source tells you whether data came from .cache or .server — invaluable for debugging cache behavior.

26.4 Logout

func logout() async {
    await TokenStore.shared.clear()
    Network.apollo.clearCache { _ in }
}

Always clear both the token and the cache. Otherwise the next user signing in on the same device sees stale data from the previous account.

27. Pagination Patterns

Two common pagination flavors: cursor-based (modern, Relay-style) and offset-based (traditional, simpler).

27.1 Cursor-based pagination

The schema uses connection types:

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
}
type PostEdge {
  node: Post!
  cursor: String!
}
type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}
type Query {
  feed(first: Int!, after: String): PostConnection!
}

Why this shape? Because cursors are stable under inserts. If you're paginating by offset and someone inserts a new item at position 5 between two of your fetches, you'd see item at offset 10 twice (once at offset 10 in fetch 1, again at offset 11 in fetch 2). Cursors avoid this — each cursor is an opaque position pointer that the server resolves correctly even as the underlying data shifts.

The query:

query Feed($first: Int!, $after: String) {
  feed(first: $first, after: $after) {
    edges {
      node {
        id
        title
        author { id name }
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

A view model managing pagination:

@Observable
final class FeedViewModel {
    var posts: [FeedQuery.Data.Feed.Edge.Node] = []
    var isLoadingMore = false
    var hasError: Error?
    private var endCursor: String?
    private var hasNextPage = true

    @MainActor
    func loadInitial() async {
        posts = []
        endCursor = nil
        hasNextPage = true
        await loadMore()
    }

    @MainActor
    func loadMore() async {
        guard !isLoadingMore, hasNextPage else { return }
        isLoadingMore = true
        defer { isLoadingMore = false }

        do {
            let data = try await Network.apollo.fetchAsync(
                query: FeedQuery(
                    first: 20,
                    after: GraphQLNullable(optional: endCursor)
                )
            )
            posts.append(contentsOf: data.feed.edges.map(\.node))
            endCursor = data.feed.pageInfo.endCursor
            hasNextPage = data.feed.pageInfo.hasNextPage
        } catch {
            hasError = error
        }
    }
}

The view triggers loadMore when the last cell appears:

struct FeedView: View {
    @State private var vm = FeedViewModel()

    var body: some View {
        List {
            ForEach(vm.posts, id: \.id) { post in
                PostCell(post: post)
                    .onAppear {
                        if post.id == vm.posts.last?.id {
                            Task { await vm.loadMore() }
                        }
                    }
            }
            if vm.isLoadingMore {
                ProgressView().frame(maxWidth: .infinity)
            }
        }
        .task { await vm.loadInitial() }
    }
}

27.2 The cache pitfall with pagination

Look closely: FeedQuery(first: 20, after: nil) and FeedQuery(first: 20, after: "abc") are different queries from the cache's point of view — they have different argument tuples. So each page is cached as a separate entry. You can't watch(query: FeedQuery(...)) and see all pages merged.

Two solutions:

  1. Hold the merged list in your view model (what we did above). Simple, but you lose normalized-cache update semantics for the merged list — if a post in page 2 gets liked, the watcher on page 2's cache entry fires, but your in-memory posts array doesn't auto-update.
  2. Configure a custom merge policy. Apollo iOS lets you customize how connection fields merge. The pattern: ignore the after argument when computing the cache key for that field, and append incoming edges to existing. More involved; only worth it if you need watchers across pages.

For most apps, option 1 is fine.

27.3 Offset-based pagination

Same idea, simpler:

query Feed($limit: Int!, $offset: Int!) {
  feed(limit: $limit, offset: $offset) {
    id
    title
  }
}
private var offset = 0
@MainActor
func loadMore() async {
    let data = try? await Network.apollo.fetchAsync(query: FeedQuery(limit: 20, offset: offset))
    if let new = data?.feed {
        posts.append(contentsOf: new)
        offset += new.count
    }
}

Cursor-based is the modern preference but offset is fine for stable, infrequently-changing lists.

28. Subscriptions and WebSockets

Subscriptions are streamed from the server over a long-lived connection. WebSocket is the standard transport.

28.1 Setting up a split transport

You add a separate WebSocket transport and route subscriptions through it:

import Apollo
import ApolloAPI
import ApolloWebSocket

enum Network {
    static let apollo: ApolloClient = {
        let httpURL = URL(string: "https://api.example.com/graphql")!
        let wsURL = URL(string: "wss://api.example.com/graphql")!

        let store = ApolloStore()

        let httpTransport = RequestChainNetworkTransport(
            interceptorProvider: DefaultInterceptorProvider(store: store),
            endpointURL: httpURL
        )

        let wsClient = WebSocket(url: wsURL, protocol: .graphql_ws)
        let wsTransport = WebSocketTransport(websocket: wsClient)

        let split = SplitNetworkTransport(
            uploadingNetworkTransport: httpTransport,
            webSocketNetworkTransport: wsTransport
        )

        return ApolloClient(networkTransport: split, store: store)
    }()
}

SplitNetworkTransport routes queries and mutations over HTTP, subscriptions over WebSocket. Two protocols are common: graphql_ws (older, also called "subscriptions-transport-ws") and graphql_transport_ws (newer, called "graphql-ws"). Match what your server speaks. Modern servers prefer graphql_transport_ws.

28.2 A subscription operation

subscription PostAdded($authorId: ID) {
  postAdded(authorId: $authorId) {
    id
    title
    publishedAt
    author { id name }
  }
}

After codegen, the call site:

let cancellable = Network.apollo.subscribe(
    subscription: PostAddedSubscription(authorId: GraphQLNullable(optional: nil))
) { result in
    switch result {
    case .success(let response):
        if let newPost = response.data?.postAdded {
            // Handle new post
        }
    case .failure(let error):
        print("Subscription error:", error)
    }
}

// To stop subscribing:
cancellable.cancel()

28.3 Bridging to AsyncSequence

The callback API doesn't compose well with structured concurrency. Wrap it in an AsyncThrowingStream:

func observeNewPosts(authorId: String?) -> AsyncThrowingStream<PostAddedSubscription.Data, Error> {
    AsyncThrowingStream { continuation in
        let cancellable = Network.apollo.subscribe(
            subscription: PostAddedSubscription(authorId: GraphQLNullable(optional: authorId))
        ) { result in
            switch result {
            case .success(let response):
                if let data = response.data {
                    continuation.yield(data)
                } else if let firstError = response.errors?.first {
                    continuation.finish(throwing: firstError)
                }
            case .failure(let error):
                continuation.finish(throwing: error)
            }
        }

        continuation.onTermination = { _ in
            cancellable.cancel()
        }
    }
}

Now consume it from SwiftUI:

.task {
    do {
        for try await update in observeNewPosts(authorId: nil) {
            posts.insert(update.postAdded, at: 0)
        }
    } catch {
        print("subscription ended:", error)
    }
}

When the view's task is cancelled (view disappears, etc.), the onTermination closure runs, cancelling the subscription. Clean lifecycle, no leaks.

28.4 Subscription gotchas

29. The Three Layers of Errors

GraphQL errors live at three levels. You handle each differently.

29.1 Transport errors

The HTTP request itself failed. DNS error, no internet, TLS failure, server returned 500. These come back as URLError from URLSession. Treat them like any iOS network error: show retry UI, check connectivity, exponential backoff.

do {
    let data = try await Network.apollo.fetchAsync(query: ...)
} catch let urlError as URLError {
    // Network-level problem
    switch urlError.code {
    case .notConnectedToInternet, .networkConnectionLost:
        // Show "no connection" UI
    case .timedOut:
        // Retry
    default:
        // Generic
    }
}

29.2 GraphQL errors

The HTTP request succeeded (status 200), but the GraphQL response has an errors array. Each GraphQLError has at minimum a message, often a path, and an extensions object the server uses for typed error codes.

A commonly-used (but unspecified) convention is extensions.code for machine-readable error categories: UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, BAD_USER_INPUT. Coordinate with backend on the codes used.

do {
    let data = try await Network.apollo.fetchAsync(query: ...)
} catch let gqlError as GraphQLError {
    if let code = gqlError.extensions?["code"] as? String {
        switch code {
        case "UNAUTHENTICATED":
            // Trigger re-login flow
        case "FORBIDDEN":
            // Show "no access" UI
        case "NOT_FOUND":
            // Empty state
        default:
            // Generic
        }
    }
}

29.3 Partial responses

This is the one REST veterans miss. A GraphQL response can have both data and errors:

{
  "data": {
    "user": { "id": "1", "name": "Ada", "email": null }
  },
  "errors": [
    { "message": "Email service timed out", "path": ["user", "email"] }
  ]
}

The server resolved name successfully but couldn't resolve email. So it returned what it had and reported the failure. This is valid and often desirable — the user's profile screen can render without the email field.

Our async wrapper from Chapter 12 throws on the first error, losing partial data. For partial-success-tolerant screens, use the lower-level callback API:

func fetchUserAllowingPartial(id: String) async throws -> (user: GetUserQuery.Data.User?, errors: [GraphQLError]) {
    try await withCheckedThrowingContinuation { continuation in
        Network.apollo.fetch(query: GetUserQuery(id: id)) { result in
            switch result {
            case .success(let response):
                continuation.resume(returning: (response.data?.user, response.errors ?? []))
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }
}

The caller decides what to do with the partial result. For a profile screen, render what you have and show a banner "couldn't load some fields." For a transactional flow (creating an order), partial data probably means failure — surface it as such.

29.4 Logging errors meaningfully

Production logging should capture, for every error:

A small helper:

func logGraphQLError(_ error: GraphQLError, operationName: String) {
    let code = (error.extensions?["code"] as? String) ?? "UNKNOWN"
    let path = error.path?.map(\.description).joined(separator: ".") ?? "—"
    Analytics.shared.log(
        event: "graphql_error",
        properties: [
            "operation": operationName,
            "code": code,
            "path": path,
            "message": error.message ?? "",
        ]
    )
}

In your APM, build dashboards keyed by operation × code. You'll spot regressions per-screen.

30. Custom Scalars

Built-in scalars (Int, Float, String, Boolean, ID) are five. Anything else is custom — DateTime, URL, UUID, JSON, Decimal, BigInt, etc.

By default, Apollo treats unknown scalars as strings. To map them to richer Swift types, configure the codegen.

In apollo-codegen-config.json:

"options": {
  "schemaCustomization": {
    "customTypeNames": {
      "DateTime": { "typeName": "Foundation.Date" },
      "URL": { "typeName": "Foundation.URL" },
      "UUID": { "typeName": "Foundation.UUID" }
    }
  }
}

Then provide an extension that conforms Date (and friends) to Apollo's CustomScalarType:

import ApolloAPI
import Foundation

extension Date: @retroactive CustomScalarType {
    public init(_jsonValue value: JSONValue) throws {
        guard let string = value as? String,
              let date = ISO8601DateFormatter().date(from: string) else {
            throw JSONDecodingError.couldNotConvert(value: value, to: Date.self)
        }
        self = date
    }

    public var _jsonValue: JSONValue {
        ISO8601DateFormatter().string(from: self)
    }
}

After regenerating, fields typed DateTime in the schema appear as Date in your Swift types. No more parsing in view models.

For server formats other than ISO-8601, replace ISO8601DateFormatter() with your matching parser.

31. File Uploads

GraphQL is JSON over HTTP — files don't fit. The community GraphQL multipart request spec bolts file uploads onto GraphQL using multipart/form-data.

The schema declares an Upload scalar:

scalar Upload

type Mutation {
  uploadAvatar(file: Upload!): User!
}

Client side:

import Apollo
import ApolloAPI

guard let imageData = UIImage(...).jpegData(compressionQuality: 0.8) else { return }

let upload = GraphQLFile(
    fieldName: "file",
    originalName: "avatar.jpg",
    mimeType: "image/jpeg",
    data: imageData
)

Network.apollo.upload(
    operation: UploadAvatarMutation(file: "file"),
    files: [upload]
) { result in
    // ...
}

A few things to notice. The mutation's file variable receives the field name string ("file"), not the bytes. The actual bytes go in the files: array as GraphQLFile instances. Apollo composes the multipart request per spec.

This works only if your server speaks the multipart spec. Many backends prefer a separate non-GraphQL endpoint for uploads (POST /upload returns a URL), and the client passes that URL into a regular GraphQL mutation. Coordinate with backend.

32. Testing GraphQL Code

Three layers of testing, each with a different approach.

32.1 Unit-test view models with service fakes

The cleanest pattern: depend on a service interface, not on ApolloClient directly.

protocol CountriesServicing {
    func fetchCountries() async throws -> [GetCountriesQuery.Data.Country]
}

final class ApolloCountriesService: CountriesServicing {
    func fetchCountries() async throws -> [GetCountriesQuery.Data.Country] {
        let data = try await Network.apollo.fetchAsync(query: GetCountriesQuery())
        return data.countries
    }
}

final class FakeCountriesService: CountriesServicing {
    var stubbedCountries: [GetCountriesQuery.Data.Country] = []
    var stubbedError: Error?

    func fetchCountries() async throws -> [GetCountriesQuery.Data.Country] {
        if let error = stubbedError { throw error }
        return stubbedCountries
    }
}

In tests, hand the view model a FakeCountriesService with stubbed data. Verify state transitions.

To construct stubbed GetCountriesQuery.Data.Country values, use the memberwise init — this is why selectionSetInitializers.operations: true is non-negotiable in the codegen config:

let canada = GetCountriesQuery.Data.Country(
    code: "CA",
    name: "Canada",
    emoji: "🇨🇦",
    capital: "Ottawa",
    continent: .init(name: "North America")
)

32.2 Integration tests with MockNetworkTransport

When you genuinely want to test the cache + interceptor pipeline together, Apollo has MockNetworkTransport for stubbing responses at the transport level. Set it up in the codegen config:

"output": {
  "testMocks": { "swiftPackage": { "targetName": "CountriesGraphQLTestMocks" } },
  ...
}

Regenerate. A new SPM target appears. Add it to your test target. Now you can write tests that exercise the full Apollo stack with stubbed responses — useful for testing cache behavior, interceptors, error handling. Heavier setup; reach for it when service-level fakes don't cover the case.

32.3 Snapshot test operation strings

If your team is paranoid about query drift, snapshot-test the generated query strings:

import Testing

@Test func GetCountriesQuery_documentIsStable() {
    let document = GetCountriesQuery().__operationDefinition  // accessor varies by Apollo version
    // Compare against a saved snapshot
}

A schema rename should change generated code, change the snapshot, and force a code review. Niche but valuable for libraries with strict API stability.


Part VII — Architecture and SwiftUI

33. SwiftUI + @Observable Integration

You want SwiftUI screens that watch queries, update reactively, and clean up after themselves. Here's a reusable @Observable wrapper:

import Apollo
import ApolloAPI
import Observation
import SwiftUI

@Observable
final class GraphQLQueryObserver<Query: GraphQLQuery> {
    enum State {
        case idle
        case loading
        case loaded(Query.Data)
        case failed(Error)
    }

    private(set) var state: State = .idle
    @ObservationIgnored private var watcher: GraphQLQueryWatcher<Query>?
    @ObservationIgnored private let client: ApolloClient

    init(client: ApolloClient = Network.apollo) {
        self.client = client
    }

    func start(query: Query, cachePolicy: CachePolicy = .returnCacheDataAndFetch) {
        state = .loading
        watcher = client.watch(query: query, cachePolicy: cachePolicy) { [weak self] result in
            Task { @MainActor in
                guard let self else { return }
                switch result {
                case .success(let response):
                    if let data = response.data {
                        self.state = .loaded(data)
                    } else if let firstError = response.errors?.first {
                        self.state = .failed(firstError)
                    }
                case .failure(let error):
                    self.state = .failed(error)
                }
            }
        }
    }

    func cancel() {
        watcher?.cancel()
        watcher = nil
    }

    deinit {
        watcher?.cancel()
    }
}

Use it in a screen:

struct CountriesScreen: View {
    @State private var observer = GraphQLQueryObserver<GetCountriesQuery>()

    var body: some View {
        Group {
            switch observer.state {
            case .idle, .loading:
                ProgressView()
            case .loaded(let data):
                List(data.countries, id: \.code) { country in
                    CountryCell(country: country.fragments.countrySummary)
                }
            case .failed(let error):
                ContentUnavailableView(
                    "Couldn't load countries",
                    systemImage: "exclamationmark.triangle",
                    description: Text(error.localizedDescription)
                )
            }
        }
        .task {
            observer.start(query: GetCountriesQuery())
        }
        .onDisappear { observer.cancel() }
    }
}

The task modifier kicks off the watch when the view appears. onDisappear cancels it. Clean lifecycle, automatic cache-driven updates, three-state UI in a few dozen lines.

For mutations from SwiftUI, prefer a dedicated view model that owns the loading and error state:

@Observable
final class LikeButtonModel {
    var isPending = false
    var lastError: Error?

    @MainActor
    func toggle(post: Post) async {
        isPending = true
        defer { isPending = false }
        do {
            try await Network.apollo.performAsync(mutation: LikePostMutation(id: post.id))
        } catch {
            lastError = error
        }
    }
}

In the view:

struct LikeButton: View {
    let post: Post
    @State private var model = LikeButtonModel()

    var body: some View {
        Button {
            Task { await model.toggle(post: post) }
        } label: {
            Image(systemName: post.isLikedByMe ? "heart.fill" : "heart")
        }
        .disabled(model.isPending)
    }
}

Combine with the optimistic update pattern from Chapter 25 and the heart flips instantly on tap.

34. The Repository Pattern

The casual Network.apollo.fetchAsync(...) calls scattered through this tutorial don't scale. Production iOS apps need:

The standard answer: Repository pattern.

34.1 Define domain types

Generated GetCountriesQuery.Data.Country is fine for narrow code. For shared business logic, define your own:

struct Country: Equatable, Identifiable {
    let id: String       // ISO code
    let name: String
    let emoji: String
    let capital: String?
    let continentName: String

    init(_ generated: CountrySummary) {
        self.id = generated.code
        self.name = generated.name
        self.emoji = generated.emoji
        self.capital = generated.capital
        self.continentName = generated.continent.name
    }
}

The mapping is dull but valuable. View models, business logic, and other repositories speak Country, not GetCountriesQuery.Data.Country.

34.2 Repository protocol + implementation

protocol CountryRepository {
    func countries() async throws -> [Country]
    func country(code: String) async throws -> Country?
}

final class ApolloCountryRepository: CountryRepository {
    private let client: ApolloClient

    init(client: ApolloClient) {
        self.client = client
    }

    func countries() async throws -> [Country] {
        let data = try await client.fetchAsync(query: GetCountriesQuery())
        return data.countries.map { Country($0.fragments.countrySummary) }
    }

    func country(code: String) async throws -> Country? {
        let data = try await client.fetchAsync(query: GetCountryDetailsQuery(code: ID(code)))
        return data.country.map { Country($0.fragments.countrySummary) }
    }
}

34.3 Dependency injection via SwiftUI environment

private struct CountryRepositoryKey: EnvironmentKey {
    static let defaultValue: any CountryRepository = ApolloCountryRepository(client: Network.apollo)
}

extension EnvironmentValues {
    var countryRepository: any CountryRepository {
        get { self[CountryRepositoryKey.self] }
        set { self[CountryRepositoryKey.self] = newValue }
    }
}

Inject from views:

struct CountriesScreen: View {
    @Environment(\.countryRepository) private var repo
    @State private var countries: [Country] = []

    var body: some View {
        List(countries) { country in
            // ...
        }
        .task {
            countries = (try? await repo.countries()) ?? []
        }
    }
}

For tests, override in previews and unit tests:

#Preview {
    CountriesScreen()
        .environment(\.countryRepository, FakeCountryRepository(stubbed: [.canada, .france]))
}

This pattern gives you:

The cost: a mapping layer between generated types and domain types. For most production apps, paying it is unambiguously the right call. For tiny apps, you might skip it and use generated types directly. Your call.

35. Performance Levers

In rough order of impact:

1. Use the cache properly. .returnCacheDataAndFetch for primary screens. Fragments to maximize cache reuse. Always select id (or your cache key) on every typed object. This single set of habits is worth more than every other optimization combined.

2. Persist the cache to SQLite. Cold-start time on data-heavy screens drops dramatically. Trivial to enable (Chapter 23).

3. Persisted queries. A production-grade optimization: Apollo can hash your queries at build time and send only the hash on the wire. The server resolves the hash from a registry. Saves bytes, prevents arbitrary queries from being sent, makes caching at CDN level possible. Configure with the operationManifest option in your codegen config and a server registry. Worth doing for high-traffic apps.

4. Profile with Instruments. Large GraphQL responses can be a JSON parse hotspot. If you see spikes during decoding, paginate harder or move work off the main thread (using actors as we have throughout).

5. Batch operations. Apollo's default transport doesn't batch, but BatchedNetworkTransport exists. Useful when one screen fires 5+ small queries in parallel — you'd consolidate them into one HTTP request.

36. Pitfalls Compendium

A summary of the most common mistakes, gathered for reference:

Forgetting id (or your cache key) in selection sets. Cache normalization breaks; you get per-query duplicates. Add the cache key field to every fragment.

Anonymous operations. query { ... } without a name. Server logs lose visibility. Code gen breaks. Always name operations.

Forgetting to cancel watchers. Every client.watch returns a GraphQLQueryWatcher that you must cancel(). Use the @Observable wrapper from Chapter 33 so cancellation is in one place.

GraphQLNullable confusion. The three cases (some, null, none) take ten minutes to internalize. Once internalized, this stops biting.

Treating partial responses as errors. You lose useful data. Decide per-query whether partial-data is acceptable; fall back to the lower-level callback API when it is.

Logging tokens or PII. Easy to do accidentally with a logging interceptor that prints request headers. Sanitize before logging.

Not running codegen in CI. Ship a build with stale generated code, ship a bug. Either commit generated code and run codegen as a CI check, or run codegen as a build phase and trust it.

Mutating the cache without first ensuring it's populated. transaction.update(query:) throws if the cache miss. Either ensure the query has been observed before mutating, or catch the missing-data error.

Using [] argument syntax wrong. arguments: ["filter": .null] in generated code is Apollo's runtime representation, not Swift literal syntax. You don't write this — codegen does. Mention here just so it doesn't surprise you in stack traces.

Schema introspection disabled in production. Many backends disable introspection in prod for security. If you can't fetch-schema against prod, fetch against staging, or get the SDL file directly from the backend team and check it into the repo.

Server returns null where you expected a value. This is almost always a nullability mismatch — the schema says nullable, but you assumed non-null. Re-check the schema. If the schema is wrong, push for a fix.


Appendix

Glossary of Terms

Argument — A value passed to a field. country(code: "CA")code is the argument name, "CA" is the value.

Cache normalization — Storing entities once by identity, with references between them. The opposite of storing nested copies.

Cache policy — Apollo's per-fetch knob that decides how the cache and network interact. Five values (Chapter 21).

Codegen / Code generation — The build-time process where Apollo's CLI reads your schema and .graphql files and emits Swift types.

Document — A .graphql file. Can contain multiple operations and fragments.

Field — A property of a type. name: String! declares a field name of type non-null String.

Fragment — A named, reusable selection set on a specific type. The composition primitive of GraphQL.

Inline fragment... on User { ... } syntax used inside a selection set on a union or interface to discriminate by concrete type.

Input type — A type used for complex arguments. Declared with input keyword.

Interceptor — Apollo's middleware hook. Runs on every request; can modify request/response.

Introspection — A built-in GraphQL query (__schema) that returns the full schema. How tools (and Apollo's CLI) discover what an API exposes.

Mutation — Operation type for writes. Server runs fields serially.

Non-null! after a type. The server guarantees this is never null.

Normalized cache — See "cache normalization."

Operation — A query, mutation, or subscription. Top-level executable unit.

Operation document — The full text of an operation as it goes over the wire.

Optimistic update — Writing a guess at the result to the cache before the server responds. UI updates instantly; reconciles when the server replies.

Persisted query — A registered query identified by hash; client sends just the hash.

Query — Operation type for reads. Side-effect-free, parallelizable, cacheable.

Resolver — Server-side function that produces a field's value. You only deal with these as a server author; clients don't see them.

Schema — The full type contract of a GraphQL server, written in SDL.

SDL (Schema Definition Language) — The text format used to write schemas.

Selection set — The { ... } after a field, listing which fields you want on that object.

Subscription — Operation type for streams. Long-lived; typically over WebSocket.

__typename — A magic field every type exposes that returns the runtime concrete type name. Used by Apollo for cache normalization and union/interface discrimination.

Variable — A typed input to an operation, prefixed with $. Sent separately from the query string.

Watcher — Apollo's observable query handle. Fires every time the cached data for a query changes.

Further Reading

You now have everything you need to build production GraphQL-powered iOS apps. Start small — wire up Apollo, run one query, render a screen — and reach for the more advanced patterns (subscriptions, optimistic updates, custom interceptors) as your app's complexity demands them. Happy querying.